/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Haystack Software Inc. All rights reserved.
 *  Licensed under the PolyForm Strict License 1.0.0. See License.txt in the project root for
 *  license information.
 *--------------------------------------------------------------------------------------------*/

/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See code-license.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { isFirefox } from "vs/base/browser/browser"
import { DataTransfers } from "vs/base/browser/dnd"
import {
  $,
  addDisposableListener,
  append,
  clearNode,
  EventHelper,
  EventType,
  getWindow,
  isHTMLElement,
  trackFocus,
} from "vs/base/browser/dom"
import { DomEmitter } from "vs/base/browser/event"
import { StandardKeyboardEvent } from "vs/base/browser/keyboardEvent"
import { Gesture, EventType as TouchEventType } from "vs/base/browser/touch"
import { IBoundarySashes, Orientation } from "vs/base/browser/ui/sash/sash"
import { Color, RGBA } from "vs/base/common/color"
import { Emitter, Event } from "vs/base/common/event"
import { KeyCode } from "vs/base/common/keyCodes"
import {
  Disposable,
  DisposableStore,
  IDisposable,
} from "vs/base/common/lifecycle"
import { ScrollEvent } from "vs/base/common/scrollable"
import "vs/css!./paneview"
import { localize } from "vs/nls"
import { IView, Sizing, SplitView } from "./splitview"

export interface IPaneOptions {
  minimumBodySize?: number
  maximumBodySize?: number
  expanded?: boolean
  orientation?: Orientation
  title: string
  titleDescription?: string
}

export interface IPaneStyles {
  readonly dropBackground: string | undefined
  readonly headerForeground: string | undefined
  readonly headerBackground: string | undefined
  readonly headerBorder: string | undefined
  readonly leftBorder: string | undefined
}

/**
 * A Pane is a structured SplitView view.
 *
 * WARNING: You must call `render()` after you construct it.
 * It can't be done automatically at the end of the ctor
 * because of the order of property initialization in TypeScript.
 * Subclasses wouldn't be able to set own properties
 * before the `render()` call, thus forbidding their use.
 */
export abstract class Pane extends Disposable implements IView {
  private static readonly HEADER_SIZE = 22

  readonly element: HTMLElement
  private header!: HTMLElement
  private body!: HTMLElement

  protected _expanded: boolean
  protected _orientation: Orientation

  private expandedSize: number | undefined = undefined
  private _headerVisible = true
  private _collapsible = true
  private _bodyRendered = false
  private _minimumBodySize: number
  private _maximumBodySize: number
  private _ariaHeaderLabel: string
  private styles: IPaneStyles = {
    dropBackground: undefined,
    headerBackground: undefined,
    headerBorder: undefined,
    headerForeground: undefined,
    leftBorder: undefined,
  }
  private animationTimer: number | undefined = undefined

  private readonly _onDidChange = this._register(
    new Emitter<number | undefined>()
  )
  readonly onDidChange: Event<number | undefined> = this._onDidChange.event

  private readonly _onDidChangeExpansionState = this._register(
    new Emitter<boolean>()
  )
  readonly onDidChangeExpansionState: Event<boolean> =
    this._onDidChangeExpansionState.event

  get ariaHeaderLabel(): string {
    return this._ariaHeaderLabel
  }

  set ariaHeaderLabel(newLabel: string) {
    this._ariaHeaderLabel = newLabel
    this.header.setAttribute("aria-label", this.ariaHeaderLabel)
  }

  get draggableElement(): HTMLElement {
    return this.header
  }

  get dropTargetElement(): HTMLElement {
    return this.element
  }

  get dropBackground(): string | undefined {
    return this.styles.dropBackground
  }

  get minimumBodySize(): number {
    return this._minimumBodySize
  }

  set minimumBodySize(size: number) {
    this._minimumBodySize = size
    this._onDidChange.fire(undefined)
  }

  get maximumBodySize(): number {
    return this._maximumBodySize
  }

  set maximumBodySize(size: number) {
    this._maximumBodySize = size
    this._onDidChange.fire(undefined)
  }

  private get headerSize(): number {
    return this.headerVisible ? Pane.HEADER_SIZE : 0
  }

  get minimumSize(): number {
    const headerSize = this.headerSize
    const expanded = !this.headerVisible || this.isExpanded()
    const minimumBodySize = expanded ? this.minimumBodySize : 0

    return headerSize + minimumBodySize
  }

  get maximumSize(): number {
    const headerSize = this.headerSize
    const expanded = !this.headerVisible || this.isExpanded()
    const maximumBodySize = expanded ? this.maximumBodySize : 0

    return headerSize + maximumBodySize
  }

  orthogonalSize: number = 0

  constructor(options: IPaneOptions) {
    super()
    this._expanded =
      typeof options.expanded === "undefined" ? true : !!options.expanded
    this._orientation =
      typeof options.orientation === "undefined"
        ? Orientation.VERTICAL
        : options.orientation
    this._ariaHeaderLabel = localize(
      "viewSection",
      "{0} Section",
      options.title
    )
    this._minimumBodySize =
      typeof options.minimumBodySize === "number"
        ? options.minimumBodySize
        : this._orientation === Orientation.HORIZONTAL
        ? 200
        : 120
    this._maximumBodySize =
      typeof options.maximumBodySize === "number"
        ? options.maximumBodySize
        : Number.POSITIVE_INFINITY

    this.element = $(".pane")
  }

  isExpanded(): boolean {
    return this._expanded
  }

  setExpanded(expanded: boolean): boolean {
    if (!expanded && !this.collapsible) {
      return false
    }

    if (this._expanded === !!expanded) {
      return false
    }

    this.element?.classList.toggle("expanded", expanded)

    this._expanded = !!expanded
    this.updateHeader()

    if (expanded) {
      if (!this._bodyRendered) {
        this.renderBody(this.body)
        this._bodyRendered = true
      }

      if (typeof this.animationTimer === "number") {
        getWindow(this.element).clearTimeout(this.animationTimer)
      }
      append(this.element, this.body)
    } else {
      this.animationTimer = getWindow(this.element).setTimeout(() => {
        this.body.remove()
      }, 200)
    }

    this._onDidChangeExpansionState.fire(expanded)
    this._onDidChange.fire(expanded ? this.expandedSize : undefined)
    return true
  }

  get headerVisible(): boolean {
    return this._headerVisible
  }

  set headerVisible(visible: boolean) {
    if (this._headerVisible === !!visible) {
      return
    }

    this._headerVisible = !!visible
    this.updateHeader()
    this._onDidChange.fire(undefined)
  }

  get collapsible(): boolean {
    return this._collapsible
  }

  set collapsible(collapsible: boolean) {
    if (this._collapsible === !!collapsible) {
      return
    }

    this._collapsible = !!collapsible
    this.updateHeader()
  }

  get orientation(): Orientation {
    return this._orientation
  }

  set orientation(orientation: Orientation) {
    if (this._orientation === orientation) {
      return
    }

    this._orientation = orientation

    if (this.element) {
      this.element.classList.toggle(
        "horizontal",
        this.orientation === Orientation.HORIZONTAL
      )
      this.element.classList.toggle(
        "vertical",
        this.orientation === Orientation.VERTICAL
      )
    }

    if (this.header) {
      this.updateHeader()
    }
  }

  render(): void {
    this.element.classList.toggle("expanded", this.isExpanded())
    this.element.classList.toggle(
      "horizontal",
      this.orientation === Orientation.HORIZONTAL
    )
    this.element.classList.toggle(
      "vertical",
      this.orientation === Orientation.VERTICAL
    )

    this.header = $(".pane-header")
    append(this.element, this.header)
    this.header.setAttribute("tabindex", "0")
    // Use role button so the aria-expanded state gets read https://github.com/microsoft/vscode/issues/95996
    this.header.setAttribute("role", "button")
    this.header.setAttribute("aria-label", this.ariaHeaderLabel)
    this.renderHeader(this.header)

    const focusTracker = trackFocus(this.header)
    this._register(focusTracker)
    this._register(
      focusTracker.onDidFocus(() => this.header.classList.add("focused"), null)
    )
    this._register(
      focusTracker.onDidBlur(
        () => this.header.classList.remove("focused"),
        null
      )
    )

    this.updateHeader()

    const eventDisposables = this._register(new DisposableStore())
    const onKeyDown = this._register(new DomEmitter(this.header, "keydown"))
    const onHeaderKeyDown = Event.map(
      onKeyDown.event,
      (e) => new StandardKeyboardEvent(e),
      eventDisposables
    )

    this._register(
      Event.filter(
        onHeaderKeyDown,
        (e) => e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space,
        eventDisposables
      )(() => this.setExpanded(!this.isExpanded()), null)
    )

    this._register(
      Event.filter(
        onHeaderKeyDown,
        (e) => e.keyCode === KeyCode.LeftArrow,
        eventDisposables
      )(() => this.setExpanded(false), null)
    )

    this._register(
      Event.filter(
        onHeaderKeyDown,
        (e) => e.keyCode === KeyCode.RightArrow,
        eventDisposables
      )(() => this.setExpanded(true), null)
    )

    this._register(Gesture.addTarget(this.header))
    ;[EventType.CLICK, TouchEventType.Tap].forEach((eventType) => {
      this._register(
        addDisposableListener(this.header, eventType, (e) => {
          if (!e.defaultPrevented) {
            this.setExpanded(!this.isExpanded())
          }
        })
      )
    })

    this.body = append(this.element, $(".pane-body"))

    // Only render the body if it will be visible
    // Otherwise, render it when the pane is expanded
    if (!this._bodyRendered && this.isExpanded()) {
      this.renderBody(this.body)
      this._bodyRendered = true
    }

    if (!this.isExpanded()) {
      this.body.remove()
    }
  }

  layout(size: number): void {
    const headerSize = this.headerVisible ? Pane.HEADER_SIZE : 0

    const width =
      this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size
    const height =
      this._orientation === Orientation.VERTICAL
        ? size - headerSize
        : this.orthogonalSize - headerSize

    if (this.isExpanded()) {
      this.body.classList.toggle("wide", width >= 600)
      this.layoutBody(height, width)
      this.expandedSize = size
    }
  }

  style(styles: IPaneStyles): void {
    this.styles = styles

    if (!this.header) {
      return
    }

    this.updateHeader()
  }

  protected updateHeader(): void {
    const expanded = !this.headerVisible || this.isExpanded()

    if (this.collapsible) {
      this.header.setAttribute("tabindex", "0")
      this.header.setAttribute("role", "button")
    } else {
      this.header.removeAttribute("tabindex")
      this.header.removeAttribute("role")
    }

    this.header.style.lineHeight = `${this.headerSize}px`
    this.header.classList.toggle("hidden", !this.headerVisible)
    this.header.classList.toggle("expanded", expanded)
    this.header.classList.toggle("not-collapsible", !this.collapsible)
    this.header.setAttribute("aria-expanded", String(expanded))

    this.header.style.color = this.collapsible
      ? this.styles.headerForeground ?? ""
      : ""
    this.header.style.backgroundColor =
      (this.collapsible ? this.styles.headerBackground : "transparent") ?? ""
    this.header.style.borderTop =
      this.styles.headerBorder && this.orientation === Orientation.VERTICAL
        ? `1px solid ${this.styles.headerBorder}`
        : ""
    this.element.style.borderLeft =
      this.styles.leftBorder && this.orientation === Orientation.HORIZONTAL
        ? `1px solid ${this.styles.leftBorder}`
        : ""
  }

  protected abstract renderHeader(container: HTMLElement): void
  protected abstract renderBody(container: HTMLElement): void
  protected abstract layoutBody(height: number, width: number): void
}

interface IDndContext {
  draggable: PaneDraggable | null
}

class PaneDraggable extends Disposable {
  private static readonly DefaultDragOverBackgroundColor = new Color(
    new RGBA(128, 128, 128, 0.5)
  )

  private dragOverCounter = 0 // see https://github.com/microsoft/vscode/issues/14470

  private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>())
  readonly onDidDrop = this._onDidDrop.event

  constructor(
    private pane: Pane,
    private dnd: IPaneDndController,
    private context: IDndContext
  ) {
    super()

    pane.draggableElement.draggable = true
    this._register(
      addDisposableListener(pane.draggableElement, "dragstart", (e) =>
        this.onDragStart(e)
      )
    )
    this._register(
      addDisposableListener(pane.dropTargetElement, "dragenter", (e) =>
        this.onDragEnter(e)
      )
    )
    this._register(
      addDisposableListener(pane.dropTargetElement, "dragleave", (e) =>
        this.onDragLeave(e)
      )
    )
    this._register(
      addDisposableListener(pane.dropTargetElement, "dragend", (e) =>
        this.onDragEnd(e)
      )
    )
    this._register(
      addDisposableListener(pane.dropTargetElement, "drop", (e) =>
        this.onDrop(e)
      )
    )
  }

  private onDragStart(e: DragEvent): void {
    if (!this.dnd.canDrag(this.pane) || !e.dataTransfer) {
      e.preventDefault()
      e.stopPropagation()
      return
    }

    e.dataTransfer.effectAllowed = "move"

    if (isFirefox) {
      // Firefox: requires to set a text data transfer to get going
      e.dataTransfer?.setData(
        DataTransfers.TEXT,
        this.pane.draggableElement.textContent || ""
      )
    }

    const dragImage = append(
      this.pane.element.ownerDocument.body,
      $(".monaco-drag-image", {}, this.pane.draggableElement.textContent || "")
    )
    e.dataTransfer.setDragImage(dragImage, -10, -10)
    setTimeout(() => dragImage.remove(), 0)

    this.context.draggable = this
  }

  private onDragEnter(e: DragEvent): void {
    if (!this.context.draggable || this.context.draggable === this) {
      return
    }

    if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) {
      return
    }

    this.dragOverCounter++
    this.render()
  }

  private onDragLeave(e: DragEvent): void {
    if (!this.context.draggable || this.context.draggable === this) {
      return
    }

    if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) {
      return
    }

    this.dragOverCounter--

    if (this.dragOverCounter === 0) {
      this.render()
    }
  }

  private onDragEnd(e: DragEvent): void {
    if (!this.context.draggable) {
      return
    }

    this.dragOverCounter = 0
    this.render()
    this.context.draggable = null
  }

  private onDrop(e: DragEvent): void {
    if (!this.context.draggable) {
      return
    }

    EventHelper.stop(e)

    this.dragOverCounter = 0
    this.render()

    if (
      this.dnd.canDrop(this.context.draggable.pane, this.pane) &&
      this.context.draggable !== this
    ) {
      this._onDidDrop.fire({ from: this.context.draggable.pane, to: this.pane })
    }

    this.context.draggable = null
  }

  private render(): void {
    let backgroundColor: string | null = null

    if (this.dragOverCounter > 0) {
      backgroundColor =
        this.pane.dropBackground ??
        PaneDraggable.DefaultDragOverBackgroundColor.toString()
    }

    this.pane.dropTargetElement.style.backgroundColor = backgroundColor || ""
  }
}

export interface IPaneDndController {
  canDrag(pane: Pane): boolean
  canDrop(pane: Pane, overPane: Pane): boolean
}

export class DefaultPaneDndController implements IPaneDndController {
  canDrag(pane: Pane): boolean {
    return true
  }

  canDrop(pane: Pane, overPane: Pane): boolean {
    return true
  }
}

export interface IPaneViewOptions {
  dnd?: IPaneDndController
  orientation?: Orientation
}

interface IPaneItem {
  pane: Pane
  disposable: IDisposable
}

export class PaneView extends Disposable {
  private dnd: IPaneDndController | undefined
  private dndContext: IDndContext = { draggable: null }
  readonly element: HTMLElement
  private paneItems: IPaneItem[] = []
  private orthogonalSize: number = 0
  private size: number = 0
  private splitview: SplitView
  private animationTimer: number | undefined = undefined

  private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>())
  readonly onDidDrop: Event<{ from: Pane; to: Pane }> = this._onDidDrop.event

  orientation: Orientation
  private boundarySashes: IBoundarySashes | undefined
  readonly onDidSashChange: Event<number>
  readonly onDidSashReset: Event<number>
  readonly onDidScroll: Event<ScrollEvent>

  constructor(container: HTMLElement, options: IPaneViewOptions = {}) {
    super()

    this.dnd = options.dnd
    this.orientation = options.orientation ?? Orientation.VERTICAL
    this.element = append(container, $(".monaco-pane-view"))
    this.splitview = this._register(
      new SplitView(this.element, { orientation: this.orientation })
    )
    this.onDidSashReset = this.splitview.onDidSashReset
    this.onDidSashChange = this.splitview.onDidSashChange
    this.onDidScroll = this.splitview.onDidScroll

    const eventDisposables = this._register(new DisposableStore())
    const onKeyDown = this._register(new DomEmitter(this.element, "keydown"))
    const onHeaderKeyDown = Event.map(
      Event.filter(
        onKeyDown.event,
        (e) =>
          isHTMLElement(e.target) && e.target.classList.contains("pane-header"),
        eventDisposables
      ),
      (e) => new StandardKeyboardEvent(e),
      eventDisposables
    )

    this._register(
      Event.filter(
        onHeaderKeyDown,
        (e) => e.keyCode === KeyCode.UpArrow,
        eventDisposables
      )(() => this.focusPrevious())
    )
    this._register(
      Event.filter(
        onHeaderKeyDown,
        (e) => e.keyCode === KeyCode.DownArrow,
        eventDisposables
      )(() => this.focusNext())
    )
  }

  addPane(pane: Pane, size: number, index = this.splitview.length): void {
    const disposables = new DisposableStore()
    pane.onDidChangeExpansionState(this.setupAnimation, this, disposables)

    const paneItem = { pane: pane, disposable: disposables }
    this.paneItems.splice(index, 0, paneItem)
    pane.orientation = this.orientation
    pane.orthogonalSize = this.orthogonalSize
    this.splitview.addView(pane, size, index)

    if (this.dnd) {
      const draggable = new PaneDraggable(pane, this.dnd, this.dndContext)
      disposables.add(draggable)
      disposables.add(
        draggable.onDidDrop(this._onDidDrop.fire, this._onDidDrop)
      )
    }
  }

  removePane(pane: Pane): void {
    const index = this.paneItems.findIndex((item) => item.pane === pane)

    if (index === -1) {
      return
    }

    this.splitview.removeView(
      index,
      pane.isExpanded() ? Sizing.Distribute : undefined
    )
    const paneItem = this.paneItems.splice(index, 1)[0]
    paneItem.disposable.dispose()
  }

  movePane(from: Pane, to: Pane): void {
    const fromIndex = this.paneItems.findIndex((item) => item.pane === from)
    const toIndex = this.paneItems.findIndex((item) => item.pane === to)

    if (fromIndex === -1 || toIndex === -1) {
      return
    }

    const [paneItem] = this.paneItems.splice(fromIndex, 1)
    this.paneItems.splice(toIndex, 0, paneItem)

    this.splitview.moveView(fromIndex, toIndex)
  }

  resizePane(pane: Pane, size: number): void {
    const index = this.paneItems.findIndex((item) => item.pane === pane)

    if (index === -1) {
      return
    }

    this.splitview.resizeView(index, size)
  }

  getPaneSize(pane: Pane): number {
    const index = this.paneItems.findIndex((item) => item.pane === pane)

    if (index === -1) {
      return -1
    }

    return this.splitview.getViewSize(index)
  }

  layout(height: number, width: number): void {
    this.orthogonalSize =
      this.orientation === Orientation.VERTICAL ? width : height
    this.size = this.orientation === Orientation.HORIZONTAL ? width : height

    for (const paneItem of this.paneItems) {
      paneItem.pane.orthogonalSize = this.orthogonalSize
    }

    this.splitview.layout(this.size)
  }

  setBoundarySashes(sashes: IBoundarySashes) {
    this.boundarySashes = sashes
    this.updateSplitviewOrthogonalSashes(sashes)
  }

  private updateSplitviewOrthogonalSashes(sashes: IBoundarySashes | undefined) {
    if (this.orientation === Orientation.VERTICAL) {
      this.splitview.orthogonalStartSash = sashes?.left
      this.splitview.orthogonalEndSash = sashes?.right
    } else {
      this.splitview.orthogonalEndSash = sashes?.bottom
    }
  }

  flipOrientation(height: number, width: number): void {
    this.orientation =
      this.orientation === Orientation.VERTICAL
        ? Orientation.HORIZONTAL
        : Orientation.VERTICAL
    const paneSizes = this.paneItems.map((pane) => this.getPaneSize(pane.pane))

    this.splitview.dispose()
    clearNode(this.element)

    this.splitview = this._register(
      new SplitView(this.element, { orientation: this.orientation })
    )
    this.updateSplitviewOrthogonalSashes(this.boundarySashes)

    const newOrthogonalSize =
      this.orientation === Orientation.VERTICAL ? width : height
    const newSize = this.orientation === Orientation.HORIZONTAL ? width : height

    this.paneItems.forEach((pane, index) => {
      pane.pane.orthogonalSize = newOrthogonalSize
      pane.pane.orientation = this.orientation

      const viewSize =
        this.size === 0 ? 0 : (newSize * paneSizes[index]) / this.size
      this.splitview.addView(pane.pane, viewSize, index)
    })

    this.size = newSize
    this.orthogonalSize = newOrthogonalSize

    this.splitview.layout(this.size)
  }

  private setupAnimation(): void {
    if (typeof this.animationTimer === "number") {
      getWindow(this.element).clearTimeout(this.animationTimer)
    }

    this.element.classList.add("animated")

    this.animationTimer = getWindow(this.element).setTimeout(() => {
      this.animationTimer = undefined
      this.element.classList.remove("animated")
    }, 200)
  }

  private getPaneHeaderElements(): HTMLElement[] {
    return [...this.element.querySelectorAll(".pane-header")] as HTMLElement[]
  }

  private focusPrevious(): void {
    const headers = this.getPaneHeaderElements()
    const index = headers.indexOf(
      this.element.ownerDocument.activeElement as HTMLElement
    )

    if (index === -1) {
      return
    }

    headers[Math.max(index - 1, 0)].focus()
  }

  private focusNext(): void {
    const headers = this.getPaneHeaderElements()
    const index = headers.indexOf(
      this.element.ownerDocument.activeElement as HTMLElement
    )

    if (index === -1) {
      return
    }

    headers[Math.min(index + 1, headers.length - 1)].focus()
  }

  override dispose(): void {
    super.dispose()

    this.paneItems.forEach((i) => i.disposable.dispose())
  }
}
